Skip to content

feat(spec)!: composeStacks objectConflict: 'merge' refuses object pairs whose object-level collections cannot be merged (#14848) - #16079

Merged
os-project-manager merged 3 commits into
mainfrom
claude/issue-14848-object-conflict-merge-refuses-collections
Sep 6, 2026
Merged

feat(spec)!: composeStacks objectConflict: 'merge' refuses object pairs whose object-level collections cannot be merged (#14848)#16079
os-project-manager merged 3 commits into
mainfrom
claude/issue-14848-object-conflict-merge-refuses-collections

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #14848

composeStacks({ objectConflict: 'merge' }) now refuses two same-name objects that both declare an object-level collection other than fields with different values — the refusal shape 'error' uses, naming the object, the colliding collection and both stacks by manifest id — instead of the later object's array replacing the earlier package's wholesale in silence. fields keeps its documented shallow merge. Clause-②: yes — a published option value's accept set narrows; the needs:contract-review carrier is the seat's to hang and clear.

Ruling (director seat, comment 5542636547 on #14848, executed as written)

Ruled: 4. In packages/spec/src/stack.zod.ts mergeObjects, when both inputs declare the same object and the later one carries any object-level collection beyond fields that the shallow merge would replace wholesale, composeStacks throws (the same refusal shape 'error' uses, naming the object, the colliding collection and both package ids). fields keeps its documented shallow merge. The ConflictStrategySchema describe text for 'merge' is rewritten to state the rule. Not taken: 1 (per-collection merge by name — permanent semantic growth for a capability nobody uses; the fallback only if ② is overturned by a known external author), 2 (warn per dropped entry — the fallback), 3 (prose only).

Maintainer provenance as recorded there: decision batch #38 item 5, verbatim reply 「同意」.

What changed (packages/spec/src/stack.zod.ts)

The collection-key set — derived, not listed

objectCollectionKeys() walks ObjectSchema.shape at first use and keeps every key whose declared type is an array or a record once the optional/default/nullable/readonly/catch wrappers are stripped, reading through a lazy or a pipe, and counting a union when any member is a collection (requiredPermissions admits string[] beside its object form). fields is the one exclusion, by rule. Measured on this tree, in shape order:

indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions

Two notes for the reviewer: the ruling's illustrative validation / hooks are not object-level keys on ObjectSchema — the real names are validations, and there is no object-level hooks (an undeclared key is refused by the strict parse before composition sees one, so it is no member either); and the string-array keys (highlightFields, searchableFields) are members by the same measurement — a later searchableFields replacing an earlier one is the same silent loss. A fixed-shape config object (wrapper-stripped object) is not a collection and stays on the scalar rule (see #16075).

The test pins the set against the shape in both directions with an independent walk (every collection key refuses; every other key composes with later-wins) and carries the literal list as the reviewer's copy — a collection key added to the object schema joins the refusal without an edit to the composer and shows up as a one-line test diff.

Refusal message shape

composeStacks conflict: object 'shared' is defined in multiple stacks and its 'actions' is declared with different values by 'com.example.a' (stack #0) and 'com.example.b' (stack #1).
objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions) is not merged: the later declaration would replace the earlier one wholesale, silently dropping every entry 'com.example.a' (stack #0) wrote.
Fix: declare 'actions' on 'shared' in exactly one of the two stacks, make the two declarations identical, or use { objectConflict: 'override' } to hand the whole object to the later stack.

The existing 'error' message is unchanged character for character (pinned), and 'override' is untouched.

The #14854 pins, flipped (reviewer's note 5521297578)

compose-stacks-action-key-collision.test.ts — each flipped arm is a refusal pin asserting the object, the collection and both ids, not a bare toThrow:

  • P3 (two stacks each embedding the same name on one object): the fixture's two embedded actions were byte-identical, which under identical-passes still composes; the P3 case that matters — two different declarations under one key — now differs by label. 'override' keeps the acceptance pin (B's array replaces A's); 'merge' is the refusal pin. A third pin keeps the identical-embedded pair accepted under both strategies, carried once.
  • P5b, embedding stack last ([boundToSharedB(), embeddedA()]) and its reverse: both built objects carry a different actions array, so under 'merge' both orders are refused at the object merge (the action-key envelope never appears); 'override' keeps its two original pins (collision one way, acceptance the other).
  • P4 (boundA() + boundB(), identical built copies): unchanged under both strategies — the object merge passes the identical copies and the action-key check refuses, as before.

compose-stacks-action-echo.test.ts (#14847): the three-stack 'merge' arm relied on emb1/emb2 being silently dropped — exactly the refused case — so it is a refusal pin now; the echo-once reading stays on 'override'.

actionsOwner keeps its per-object contract (#14854): a differing actions pair is refused before ownership could matter, and an identical pair is one declaration whichever stack it is attributed to (docblock says so).

New pins — compose-stacks-merge-collection-refusal.test.ts (62 tests)

The card's own case with the full message asserted; each of indexes (strict-parsed), validations, listViews, fieldGroups, searchableFields, highlightFields, activityMilestones, requiredPermissions refused by name; first-declarer naming across three stacks; manifest-less inputs named by position; the fields-only merge accepted (later fields win, earlier kept, later label wins); identical collections carried once; an absent later key keeps the earlier collection; an explicit undefined neither refuses nor erases; the built-copies pair reaching the action-key check; the default 'error' message pinned verbatim; 'override' unchanged; the two-direction shape pin over every key of ObjectSchema.shape.

Call sites (M1, re-measured)

Zero non-test call sites pass objectConflict in packages/**, examples/**, apps/** on origin/main @ 53cbad9f7 and again @ c463d03e0 — every real caller takes the default 'error'. No stop condition reached. content/docs/getting-started/examples.mdx mentions the option generically and stays; no prose in packages/**, content/docs/** or skills/** describes the old behaviour ("later fields win" hits all describe the fields merge, which still holds).

Verification (all under scripts/pm/os-verify-lock.sh, slot issue-14848; exit codes captured before any pipe)

  • pnpm --filter @objectstack/spec build — VERDICT command-exit 0 (check-dts-emitted 34/34).
  • pnpm --filter @objectstack/spec testTest Files 477 passed (477), Tests 12826 passed (12826) (post-merge run at 29e4671f6; pre-merge run 479 / 12902, both green).
  • pnpm --filter @objectstack/spec typecheck — exit 0; its check:test-typecheck leg compiles the test layer under tsconfig.test.json, so the new pins are covered.
  • pnpm --filter @objectstack/spec check:generatedAll 15 generated artifacts are up to date (one earlier red was the [finding] check:api-surface refuses on file mtimes while the spec build short-circuits on a content hash — a merge that re-checks-out an unchanged spec source yields a false "packages/spec/dist is OLDER than packages/spec/src" refusal #14985 mtime shape after a byte-identical restore; a rebuild cleared it).
  • Ablation (fix committed first): the refuseUnmergeableCollections(...) call removed from the 'merge' arm (anchor count 1 → 0, marker 0 → 1, blob differs from HEAD); the three pin files went Tests 25 failed | 74 passed — every refusal pin red, every acceptance pin green; restore git checkout HEAD -- path proven by blob hash equal to HEAD:path (370b1218…), git diff HEAD empty, porcelain empty. The subject resolves from source (./stack.zod), so no dist leg applies.
  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (post-merge tree 29e4671f6) derived 72 commands; all 72 ran; --ran reconciliation: 72 derived, 72 run, 0 unrun. 70 green after building the @objectstack/lint closure for check:doc-formula-expressions; two are NOT MEASURED locally by their own text (pnpm check:dual-build-cjs-loads, pnpm check:type-check-debt — both exit 3 "nothing was measured", prerequisite is a whole-repo pnpm build) and are declared to CI's Lint & Repo Gates.
  • Lint, proved narrowing at 29e4671f6: eslint --no-inline-config --format json over the 4 changed TS files — 4 files, 0 errors, 0 warnings; the population is the repo's own eslint.config.mjs, which states no parserOptions.project and no typed rules (line 328), so a change to these files cannot move any untouched file's verdict.
  • Consumers, declared narrowing: turbo ls --affected (merge-base c463d03e0) lists 75 packages — spec plus every consumer (cli closure 58 packages, runtime 31; beyond the local cap). check:api-surface green proves no export was added, removed or narrowed, so the consumers' compile face did not move; their typecheck and tests go to CI's required TypeScript Type Check / Test Core.
  • Merge lap: origin/main merged at c463d03e0 via scripts/pm/os-regen-merge.sh (5 commits, no conflict, no regeneration owed; feat(spec): export COMPOSE_KEY_DISPOSITIONS and STACK_DEFINITION_KEYS — the artifact envelope's top-level key set and each key's composition rule, derivable instead of hand-copied #16051 has not landed — COMPOSE_KEY_DISPOSITIONS is not exported on origin/main).

Changeset

.changeset/compose-merge-refuses-object-collections.md@objectstack/spec minor with the launch-window BREAKING banner and adr-0087: not-required (no-migration-prescription) (nothing authorable renamed, retired or re-typed; the refusal text carries the whole prescription; zero call sites). check:adr-0087-registration, check:empty-changeset, check:changeset-no-major all green.

Not addressed here

#14662 / #14854 remain the landed action-key check and are only re-pinned; #5005 remains the top-level class; #16075 remains open (config-object half of the boundary).

🤖 Generated with Claude Code

https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf


Generated by Claude Code

@github-actions github-actions Bot added the size/l label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

6 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 130 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c2520416cff04575dd3def789dd04a5745e3912bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 974f9aa842e4f5a457d5b9e50528617d3a2c5fdf — the merge of head 29e4671f6b1d95ac7fa79deec325c15117dfd3e5 into base c2520416cff04575dd3def789dd04a5745e3912b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 974f9aa842e4f5a457d5b9e50528617d3a2c5fdf && git checkout 974f9aa842e4f5a457d5b9e50528617d3a2c5fdf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c2520416cff04575dd3def789dd04a5745e3912b 29e4671f6b1d95ac7fa79deec325c15117dfd3e5 && git checkout -B drift-repro c2520416cff04575dd3def789dd04a5745e3912b && git merge --no-ff 29e4671f6b1d95ac7fa79deec325c15117dfd3e5

node scripts/docs-audit/affected-docs.mjs --json c2520416cff04575dd3def789dd04a5745e3912b

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants